Skip to content

feat(runtime): report native memory held by wrappers to V8, forward memory pressure - #507

Merged
edusperoni merged 1 commit into
mainfrom
feat/external-memory
Oct 13, 2026
Merged

edusperoni merged 1 commit into
mainfrom
feat/external-memory

Conversation

@edusperoni

@edusperoni edusperoni commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

What

The runtime never told V8 about native memory kept alive by JS wrappers. V8 only weighs its own heap (plus ArrayBuffer backing stores, which it counts itself) when scheduling GCs. So a wrapper holding a multi-MB UIImage looks like ~50 bytes and can sit uncollected until iOS jetsams the app. This PR adds reporting at three levels of confidence and forwards OS memory pressure.

Accounting primitive

  • ExternalMemoryCharge (wrapping v8::ExternalMemoryAccounter, the non-deprecated replacement for AdjustAmountOfExternalAllocatedMemory) hangs off BaseDataWrapper. Whichever path deletes the wrapper (finalizer, __releaseNativeCounterpart, teardown, an adapter's -dealloc on another thread) returns the charge.
  • Release is thread-safe: V8's decrease is an atomic counter update that V8 itself calls from GC worker threads. The isolate's gate is pinned for the call. Once the gate is closed the decrease is skipped, because disposal drops the isolate's total anyway.

Tier 1: exact sizes the runtime allocates

  • interop.alloc(n) charges n bytes to the adopted pointer.

Tier 2: estimated sizes for objects JS likely owns

  • Estimators: NSData → length; UIImage / CGImage → bytesPerRow * height; CVPixelBuffer → CVPixelBufferGetDataSize. They are resolved once per class and cached in Caches.
  • CoreGraphics and CoreVideo functions are resolved with dlsym, so the runtime gains no link dependencies.
  • NSDataAdapter is excluded, since V8 already counts its JS buffer.
  • Symbol images are skipped, since -CGImage rasterizes them.
  • Applied only when JS most likely holds the last reference:
    • new X(...)
    • initializers
    • +1 returns (copy / new / Create)
    • class factory methods
  • Instance getters (view.image) are not charged, because the receiver usually keeps the object alive and the GCs triggered by charging it could not free anything.

Tier 3: library-declared sizes

  • interop.setExternalSize(obj, bytes) replaces the charge on a native object, pointer, reference, block or function reference; 0 clears it. interop.getExternalSize(obj) reads it back.
  • Class, protocol and type objects throw TypeError, because they live as long as the isolate and the charge could never be returned.
  • Struct objects throw TypeError too. Since perf(runtime): build struct objects from per-type templates with GC-owned storage #499 their wrapper is destroyed by the storage's backing-store deleter, which may run off-thread and must not touch V8, and V8 already counts that storage.
  • Byte counts must be in [0, 2^34], otherwise RangeError. V8 aborts the process, rather than throwing, on a single adjustment above its sanity limit.
  • This is the hook for @nativescript/core ImageSource, canvas and plugins, which know their real footprint.

Memory pressure

  • A process-wide DISPATCH_SOURCE_TYPE_MEMORYPRESSURE source maps normal/warn/critical to MemoryPressureLevel::kNone/kModerate/kCritical and calls Isolate::MemoryPressureNotification on every live isolate.
  • The notification runs outside the isolate registry lock, with each isolate's gate pinned. Off-thread, V8 requests a GC interrupt and posts a task to the isolate's runner.

Known limitations

  • Estimates are taken once, at wrap time; later NSMutableData growth is not tracked.
  • A decoded-bitmap estimate for an image loaded from a file overstates its memory until it is drawn.
  • interop.bufferFromData on a charged NSData counts the bytes twice: once for the NSData wrapper, once for the ArrayBuffer.
  • interop.setExternalSize / getExternalSize typings belong in @nativescript/types and are a follow-up.

Tests

TestRunner/app/tests/ExternalMemoryTests.js (12 specs) covers:

  • setting, replacing and clearing charges
  • rejecting invalid targets and byte counts
  • release through GC and explicit release
  • interop.alloc
  • the factory, constructor, +1 and getter paths
  • CGImage/UIImage bitmap estimates

Rebased on #499. Full suite under ASan on a dedicated simulator: 1835 specs, 0 failures, no sanitizer reports. The memory-pressure path has no automated test because the simulator has no reliable way to raise a memory-pressure event.

Summary by CodeRabbit

  • New Features

    • Added external-memory accounting for native objects and allocations, including estimated sizes for supported data and image objects.
    • Added APIs to set, retrieve, and clear memory charges for supported native wrappers.
    • Added system memory-pressure monitoring and notifications across runtime isolates.
  • Tests

    • Added coverage for memory charges, allocation accounting, estimated object sizes, invalid inputs, and charge release.

@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 21395424-5bda-426e-8475-01a9df11f1ea
📥 Commits

Reviewing files that changed from the base of the PR and between caaee30 and 061eae8.

📒 Files selected for processing (8)
  • NativeScript/runtime/ArgConverter.mm
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/Interop.h
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/InteropTypes.mm
  • NativeScript/runtime/Runtime.mm
  • TestRunner/app/tests/ExternalMemoryTests.js
  • TestRunner/app/tests/index.js

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

📝 Walkthrough

Walkthrough

The runtime adds external-memory accounting for native wrappers, APIs to set and read wrapper charges, estimates for supported native objects, and memory-pressure notification forwarding to live isolates.

Changes

External-Memory Accounting

Layer / File(s) Summary
Charge model and native size estimators
NativeScript/runtime/DataWrapper.h, NativeScript/runtime/Caches.h, NativeScript/runtime/ExternalMemory.h, NativeScript/runtime/ExternalMemory.mm, v8ios.xcodeproj/project.pbxproj
Wrappers can own external-memory charges. The runtime adjusts charges against V8 accounting and caches estimators for supported Objective-C and CF objects. The Xcode project includes the new implementation and header.
Interop APIs and automatic charges
NativeScript/runtime/ArgConverter.mm, NativeScript/runtime/Interop.h, NativeScript/runtime/Interop.mm, NativeScript/runtime/InteropTypes.mm, TestRunner/app/tests/ExternalMemoryTests.js, TestRunner/app/tests/index.js
Interop adds APIs to set and read wrapper charges. Allocations and selected native-object return paths record sizes. Tests cover charge validation, allocation charges, object estimates, and image estimates.
Memory-pressure notifications
NativeScript/runtime/ExternalMemory.mm, NativeScript/runtime/Runtime.h, NativeScript/runtime/Runtime.mm
Runtime initialization starts memory-pressure monitoring. Notifications map system statuses to V8 pressure levels and are forwarded to live isolates whose gates can be pinned.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Monitor as Dispatch memory-pressure monitor
  participant Runtime as Runtime::NotifyMemoryPressure
  participant Isolates as Live V8 isolates
  Monitor->>Runtime: forward mapped pressure level
  Runtime->>Isolates: notify isolates with pinnable gates
Loading

Suggested reviewers: nathanwalker

Merge Risk: ⚪ Minimal · up to 061ea

This change adds native-memory accounting for wrappers and forwards memory-pressure events to running isolates. No concrete defect was confirmed. A possible edge case remains unconfirmed: negative or failed interop.alloc sizes may be reported to the memory accounting. The owner may want to add input validation there as a follow-up.

Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 7 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main changes: reporting native memory held by wrappers to V8 and forwarding memory-pressure notifications.
Full details: Docstring Coverage

Explanation

Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 7 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit counts the bytes with care,
Native wrappers join the tally there.
A pointer gets its measured share,
V8 updates what it can bear.
When pressure calls, the isolates hear,
And hops the rabbit, pleased and clear.

Comment @coderabbitai help to get the list of available commands.

@edusperoni
edusperoni marked this pull request as ready for review October 12, 2026 16:09
…emory pressure

V8 only weighs its own heap (plus ArrayBuffer backing stores) when scheduling
collections, so a JS wrapper holding megabytes of native memory looks like a
few dozen bytes and can sit uncollected until the process is jetsammed.

- Wrappers can carry an ExternalMemoryCharge (a v8::ExternalMemoryAccounter)
  that is returned whenever the wrapper is deleted, from any thread; once the
  isolate's gate is closed the decrease is skipped since disposal drops it.
- interop.alloc(n) charges n bytes to the returned pointer.
- Objects JS likely owns (JS constructors, initializers, +1 returns, class
  factory methods) get an estimated size: NSData length, UIImage/CGImage
  bitmap size, CVPixelBuffer data size. CG/CV are resolved at runtime, so no
  new link dependencies. NSDataAdapter is excluded (V8 already counts it).
- interop.setExternalSize(obj, bytes) / interop.getExternalSize(obj) let
  libraries declare the native footprint of what they wrap.
- A process-wide dispatch memory-pressure source forwards normal/warn/critical
  to every live isolate via Isolate::MemoryPressureNotification.
@edusperoni
edusperoni force-pushed the feat/external-memory branch from caaee30 to 061eae8 Compare October 13, 2026 01:17
@edusperoni
edusperoni merged commit 20935bd into main Oct 13, 2026
10 checks passed
@edusperoni
edusperoni deleted the feat/external-memory branch October 13, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant